iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
AI Engineering

30 天打造 AI 後端:從 LLM、RAG 到 AI Agent系列 第 27 篇

[Day 27] 我調了 13 天的檢索,佔帳單的 0.11%

  • 分享至 

  • xImage
  •  

一、先說結論

一、帳單的 89.9% 是輸入,只有 9.9% 是輸出。(證據在第五節)
輸入是我塞給模型的東西(條文加指示),輸出是它講出來的答案。
輸出的單價確實比輸入貴 4 倍,我的 prompt 檔第一行註解就寫著「答案要短」。
但 RAG 的形狀是塞很多、講很少,那個 4 倍根本沒有機會發生。

二、我花了前 13 天調「怎麼找到對的條文」,那件事佔帳單 0.11%。(證據在第五節)
找條文要先把文字變成向量,而算向量是所有呼叫裡最便宜的一種。26 天全部的向量加起來 NT$0.0298。Day 25 量過它佔一次請求時間的 1.4%,錢這邊更極端。

三、這份帳是重建出來的,不是查出來的。(病因在第二節)
快取的 key 是整份 prompt 的雜湊,值只有答案。這是一個可以修的缺陷,下篇就在修它。

26 天總額(重建) NT$27.04
獨立呼叫 2,236 次
換算成問答 約 10,017 次(一題 0.0027 元)
最貴的一筆 Day 19 改用 gpt-4o 改考卷,NT$14.74(54%)
我還認得出來的 686/1,298(53%)

錢是怎麼分佈的:

切法 結果
依模型 gpt-4o 63.2%、gpt-4o-mini 36.7%、向量 0.1%
依 token 種類 輸入 89.9%、輸出 9.9%、向量 0.11%
依天 Day 19 一天佔 63%

gpt-4o 只在 Day 18 跟 Day 19 出現過,兩天。

二、我為什麼查不出來

這件事花掉我一整天,不是因為加法難,是因為根本沒有一張帳單可以打開。負責呼叫模型的那支函式從 Day 14 就長這樣:

if use_cache:
    cache = _load_json(CHAT_CACHE_PATH)
    key = _chat_cache_key(messages)
    if key in cache:
        return cache[key], 0, 0

那三個回傳值是「答案、用掉幾個輸入 token、用掉幾個輸出 token」。命中快取時後面兩個直接給 0,意思是「這次沒跟 OpenAI 要東西,沒被扣錢」。

那句話本身沒錯,但它讓每天報告裡那行成本變成「今天實際刷卡多少」,全命中的日子就誠實地印 0 元。對帳是正確的,只是從來沒有人把它累計起來。

更麻煩的是 key 怎麼算:

payload = json.dumps([CHAT_MODEL, messages], ensure_ascii=False, sort_keys=True)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]

整份 messages 的雜湊。雜湊是單向的,所以快取裡有 1,298 筆答案,卻沒有一筆知道當初送進去多少字。存向量那份也一樣:350 筆、10.5 MB,key 是把文字雜湊出來的,原文沒留。

我保留了買到的東西,沒保留收據。

服務這一側量得到時間,量不到錢

腳本時代的缺陷會原封不動留到服務裡。Day 24 定的回應契約長這樣:

class Usage(BaseModel):
    embedding_tokens: int = 0
    input_tokens: int = 0
    output_tokens: int = 0
    cached: bool = False

四個欄位,沒有一個是錢。Day 25 加的計時 middleware 會在回應上掛x-embed-ms、x-retrieve-ms、x-llm-ms 那幾個 header,再加一個 x-total-ms(串流那條例外,昨天那篇講過為什麼)。/healthz 則會報索引有幾塊、快取有幾筆、哪幾個 retriever 活著。毫秒有人管,塊數有人管。

錢沒有。這支服務跑到第 26 天,沒有任何一個端點回答得出「你花了多少」。

而 cached: true 那些回應更難看一點:它們的三個 token 數全是 0,因為底下那支函式就是這樣回的。呼叫端收到的訊息是「這次不用錢」,而不是「這次省了多少」。服務把那個 0 原封不動轉發出去了。

三、考古:把 prompt 重建回去對雜湊

雜湊回不去,但可以正著算。如果我能把當初那份 messages 一模一樣地重建出來,
算出的 key 就會落在快取裡。

稽核腳本第一件事是確定它不會花錢:

def disarm() -> None:
    import rag_core

    def boom(*_a, **_k):
        raise RuntimeError("Day 27 的稽核腳本不准打 API")

    rag_core.get_client = boom

一支在算帳的程式不小心打了 API,是很難看的事。任何一個沒命中的查詢會在這裡停下來。

然後把 26 天問過的組合重新拼一次。

同一個問題,我在不同天餵過三種不同的 system prompt、五種 k 值(撈 1 條、3 條、5 條、10 條、15 條給模型看)、四種來源的語料(乾淨的 markdown、PDF 抽出來的文字層、掃描件 OCR 出來的⋯)。改考卷那一側也有四種 prompt 變體、兩個模型。

把這些乘開來重組 messages,得到 96,376 組候選 key。一組對上,就代表「這筆花費當初是這樣花掉的」。

九萬組候選要做九萬次 json.dumps,而 system prompt 有一兩 KB,光序列化就是好幾分鐘。改考卷那類 messages 永遠是 system 加 user 兩則,所以把不變的前後綴先組好,只序列化會變的那一段:

def pair_key_builder(model: str, system: str):
    head = ('["' + model + '", [{"content": '
            + json.dumps(system, ensure_ascii=False) + ', "role": "system"}, '
            + '{"content": ')
    tail = ', "role": "user"}]]'

    def build(user: str) -> str:
        payload = head + json.dumps(user, ensure_ascii=False) + tail
        return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]

    return build

手工拼 JSON 是會出事的那種聰明,所以進場先跟正規寫法對一次,位元不同就當場斷掉。
差一個空白,九萬組候選會全部對不上,而那看起來就像「原來我什麼都認不出來」。

對出來的結果:

這份快取裝的是 幾筆 認得出來
主線問答,加上 Day 18 起的逐項改考卷 1,298 686(53%)
Day 19 改用 gpt-4o 重判一輪 265 95(36%)
Day 18 請 gpt-4o 盲標、跟我自己的標註對答案 23 23(100%)
Day 24 包成服務時另存的三份 54 54(100%)
所有算過的向量 350 266(76%)

認出來的那些,輸入 token 直接數重建出來的 messages,是精確的。
認不出來的用「同一個輸出長度分組裡、認得出來的那些」的平均回推,不是全體平均。改考卷的輸出只有三個字,答案有幾十個字,混在一起回推會失真。報表跟文章裡這些一律標成估計。

對不上的 612 筆有個線索:其中 382 筆的輸出在 5 個 token 以內。那不是答案,是「符合」「未提及」「相反」,改考卷那個模型的回覆就只有這三個詞。

所以認不出來的大宗不是問答,是改考卷。要重建那些呼叫,我得先湊出當初被改的那份答案,而那些答案又是更早幾天某個參數組合生出來的。湊不齊,就對不上。

數 token 這把尺得先驗

輸出 token 是 tiktoken 離線重數的,所以要先證明它跟 API 的計費一致。
前幾天的紀錄裡有 158 筆同時留了答案原文與當初 API 回報的輸出 token 數:

158/158 完全一致,最大誤差 0 個 token。

離線數中文答案跟 OpenAI 收的錢是同一個數。老實說我本來預期會有一兩個差距,
中文的分詞在不同套 tokenizer 之間常常對不齊。這次沒有。

四、26 天到底買了什麼

https://ithelp.ithome.com.tw/upload/images/20260922/20183569Mpz0ZI6I73.png
26 天帳單的兩張圖並排。左邊是九個項目由大到小的橫條:Day 19 改用 gpt-4o 改考卷 14.74 元佔 54%、問答加上逐項改考卷 3.88 元佔 14%、Day 22 請模型讀整頁圖(18 次被拒絕)3.44 元佔 13%、Day 18 請 gpt-4o 覆核我的標註 1.60 元、Day 23 看圖回答(便宜的模型)1.21 元、Day 20/21 會自己查好幾次的 agent 1.20 元、Day 23 看圖回答(貴的模型)0.76 元、Day 24 包成服務時 0.19 元、26 天所有的向量 0.03 元。深橘是貴的模型 gpt-4o,淺橘是便宜的 gpt-4o-mini,灰是向量,總共 NT$27.04、2,236 次獨立呼叫。右邊是同一筆錢換成另一種切法的堆疊長條:輸入(我塞進去的)89.9%、24.33 元,幾乎佔滿整根;輸出(模型講的)9.9%、2.69 元,薄薄一層在上面;向量(找條文用的)0.11%、0.0298 元,細到必須用一條線拉出去標]

每一份快取就是一張「已經付過錢」的清單,加起來就是這張圖。

最上面那筆是 Day 19。改考卷的模型本來是便宜的 gpt-4o-mini,我想知道換成貴 17 倍的 gpt-4o 會不會判得比較準,所以整輪重判了一次。答案是不會,兩邊都判對 31/40。而那一次對照,是整個系列最貴的一筆,NT$14.74。

第三筆更荒謬一點。Day 22 我請視覺模型逐字轉錄整頁規章,20 次呼叫裡有 18 次回我「抱歉,我無法協助處理該請求」,剩下兩次只吐出最後一章。那 20 次花了 NT$3.44,佔 26 天的 13%。圖片 token 很貴,而拒絕也算圖片 token,一張 150dpi 的 A4 進去就是 36,904 個輸入 token,不管模型願不願意讀它。

深橘色那幾條是 gpt-4o。它只出現在兩天,吃掉 63.2%。

五、輸入 89.9%,而我一直在省輸出

右邊那根柱子是今天真正的發現。

我的 system prompt 有一句「控制在三句話以內」,旁邊的註解寫得很清楚:輸出比輸入貴 4 倍。單價沒錯,gpt-4o-mini 是 0.15 對 0.60。

但 RAG 的形狀是塞三條條文、講兩句話。昨天量到一次呼叫約 450 輸入、35 輸出:

450 × 0.15 = 67.5
 35 × 0.60 = 21.0

輸入是輸出的 3.2 倍。26 天下來更極端,因為改考卷那類呼叫更偏:塞一大段判定規則進去,只換回「符合」兩個字。整體就變成 89.9% 對 9.9%。所以要省錢,該動的是 top-k 塞幾條、每條多長、system prompt 有多囉嗦。我 26 天都在調另一頭。

順著同一張圖往下看,全部的向量加起來 NT$0.0298。Day 8 到 Day 13 在調切法、k 值、相似度,Day 17 加重排,Day 22 換語料形態,前半個系列的力氣幾乎都花在找條文這一側。Day 25 量出它佔一次請求時間的 1.4%,今天這張圖說它佔錢的 0.11%。

我不覺得那些天白做了,找得到對的條文,答案才會對。但如果當初有人問我「你調的這個東西佔帳單多少」,我大概會說一成,不會說千分之一。

六、還有另一種算法,差 22%

每天的實驗報告裡本來就各記了一行成本。那些記的是「這一天的實驗值多少錢」,同一次呼叫隔幾天又用到,兩天都會各算一次。我把那 26 行直接抄下來相加,一個都沒重算:NT$34.82,比上面那張圖多 NT$7.77。

多出來的就是跨天重複用到的部分。兩種算法都對,只是問題不同:一個問「我花了多少錢」,一個問「這些實驗各自值多少錢」。

兩邊最後指向同一件事:後者裡 Day 19 一天就佔 63%,前者裡 gpt-4o 佔 63.2%。兩個 63 是巧合,但它們指的是同一個決定,就是那天我把改考卷的模型換貴了。

預測對帳

# 預測 實測
1 快取裡的答案共 48,000 個輸出 token 23,668 ✗ 高估 2.0×
2 我還認得出來的比例 55% 53% ✓
3 26 天總花費 NT$62 NT$27.04 ✗ 高估 2.3×
4 沒有快取的話要花 NT$180 每重跑一次 NT$34.82 ✗ 問題本身問錯
5 一次問答 NT$0.0028 0.0027 元 ✓ 差 4%
6 全部的向量 26,000 token 48,018 ✗ 低估 1.8×
7 最貴的單項是 Day 22 那些被拒絕的圖 它佔 12.7%,但不是最貴的 ✗
反轉候選 輸入佔帳單 76% 89.9% ✓ 方向對,低估

第 7 條我想記一下。我一直覺得「花 3.44 元買到 18 次拒絕」是這系列最貴的荒謬事,寫預測的時候毫不猶豫就填了它。實際上它排第三,前面是我換了一次改考卷的模型。荒謬的東西比較好記,貴的東西比較安靜。

第 4 條錯得更徹底。我把「快取省了多少」當成一個可以加總的數字,先寫死了 NT$180。跑到一半才發現那個問題問錯了:要知道省了多少,得先知道命中了幾次,而命中次數從來沒有人數過。這篇算得出來的,只有「全部重跑一次要花多少」。

那正好是下篇要修的東西。

下篇 Day 28(2-2):把今天這件事變成服務的一部分

今天做的是一次性的:寫一支稽核腳本,把已經發生的事挖回來。下篇是另一半,讓它不必再挖第二次。
那支跑了 23 天的核心函式不能改,它是前面每一篇的證據;快取值的格式也不能動,動了既有的 1,298 筆會整批失效。所以下篇要在那兩個限制底下,讓每一次 /ask 的回應都帶著這一次的金額,命中的那些帶著「省下多少」,而服務累計花了多少變成一次 GET。

還有兩件事留到下篇。一個是昨天那個關掉分頁走掉的使用者,那次呼叫值多少錢,明天 log 裡會有數字。另一個是我差點漏掉的:這支服務有兩種答法,而上面那張圖裡排第六的 agent,Day 21 量過它的錢是寫死管線的 5.0 倍。一份只記便宜那條路的帳本,會剛好在最需要它的地方失明。


《30 天打造 AI 後端:從 LLM、RAG 到 AI Agent》第 27 篇


上一篇
[Day 26] 使用者關掉分頁走了,我的服務還在幫他付錢
下一篇
[Day 28] 我把一個沒有煞車的迴圈掛在 API 上,掛了四天才想起來
系列文
30 天打造 AI 後端:從 LLM、RAG 到 AI Agent 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言